Skip to content

Respect pagination when querying a workspace dataframe with sql param - #859

Merged
jcelliott merged 5 commits into
mainfrom
je/fix-sql-pagination
Aug 13, 2026
Merged

Respect pagination when querying a workspace dataframe with sql param#859
jcelliott merged 5 commits into
mainfrom
je/fix-sql-pagination

Conversation

@jcelliott

Copy link
Copy Markdown
Contributor

repositories::workspaces::data_frames::query dropped its DFOpts on the branch that runs caller-supplied SQL, passing None to sql::query_df where the other branch passes Some(opts). opts is what carries page/page_size, so a read with a sql param returned the whole result set for every page.

prepare_sql now composes the page onto the statement instead of appending to it, since caller SQL can carry its own ORDER BY, LIMIT, or OFFSET.

`repositories::workspaces::data_frames::query` dropped its `DFOpts` on the
branch that runs caller-supplied SQL, passing `None` to `sql::query_df` where
the other branch passes `Some(opts)`. `opts` is what carries page/page_size, so
a read with a `sql` param returned the whole result set for every page.

`prepare_sql` now composes the page onto the statement instead of appending to
it, since caller SQL can carry its own ORDER BY, LIMIT, or OFFSET:

- A statement that bounds its own extent is paged through a subquery, so the
  two bounds nest rather than collide, and no sort of ours reorders the rows it
  already picked.
- Otherwise the statement's own ORDER BY wins over `opts.sort_by`.
- A paginated statement left with no order at all gets `ORDER BY _oxen_row_id`
  wherever that column binds — the same stable order the non-sql branch builds,
  so an edited row stays on its page. Statements that aggregate, dedupe, or read
  a derived table can't bind it and page in whatever order they produce.

The GET handler counts what the query selects rather than what the frame holds,
so `total_pages` and `total_entries` describe the same row set the page came out
of.

`PyWorkspaceDataFrame::sql_query` still returns the full result, now by reading
the pages. It stops on a short page, which is both the end of the result and
what a server too old to paginate the query answers page 1 with.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7e657ac2-1029-4ce7-a3e5-25a6587280a3

📥 Commits

Reviewing files that changed from the base of the PR and between 3e67d7d and 737bf22.

📒 Files selected for processing (2)
  • crates/oxen-py/src/py_workspace_data_frame.rs
  • crates/oxen-server/src/controllers/workspaces/data_frames.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • SQL query results now support reliable pagination while preserving requested ordering and limits.
    • Added accurate row counts for complete data frames and filtered SQL results.
    • Large Python SQL query results can be retrieved in a single request.
  • Bug Fixes

    • Improved handling of zero, oversized, and out-of-range pagination values.
    • Added clearer errors for failed, malformed, or unserializable SQL responses.
    • Added consistent default row ordering for eligible paginated results.

Walkthrough

The change adds SQL-aware row counting, preserves query-owned ordering and bounds, applies safe pagination, moves indexed operations to blocking tasks, and retrieves Python SQL results in one maximum-sized page with explicit validation.

Changes

SQL Pagination and Data-Frame Querying

Layer / File(s) Summary
SQL shape analysis and pagination preparation
crates/liboxen/src/core/db/data_frames/df_db.rs
prepare_sql analyzes SQL shape, preserves existing ordering and bounds, applies safe pagination, and uses _oxen_row_id for eligible reads. count_sql counts arbitrary SQL results.
Workspace query and count integration
crates/liboxen/src/repositories/workspaces/data_frames.rs, crates/liboxen/src/core/v_latest/data_frames.rs
Workspace SQL queries receive DFOpts, and count_for_query counts the selected SQL result. Tests cover bounded, ordered, aggregate, distinct, terminated, and non-SQL pagination.
Server execution and pagination coverage
crates/oxen-server/src/controllers/workspaces/data_frames.rs
The server performs indexed count and query operations in blocking tasks. Pagination handles indexed, unindexed, zero, maximal, out-of-range, and SQL-filtered requests.
Python SQL retrieval
crates/oxen-py/src/py_workspace_data_frame.rs
sql_query requests one maximum-sized page, validates responses and row arrays, and reports request or serialization errors.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PythonClient
  participant DataFrameController
  participant WorkspaceDataFrames
  participant DuckDB
  PythonClient->>DataFrameController: Request SQL results
  DataFrameController->>WorkspaceDataFrames: Count and execute SQL with DFOpts
  WorkspaceDataFrames->>DuckDB: Prepare and run paginated SQL
  DuckDB-->>WorkspaceDataFrames: Rows and selected-result count
  WorkspaceDataFrames-->>DataFrameController: Page data and total
  DataFrameController-->>PythonClient: Validated result response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: preserving pagination for workspace dataframe SQL queries.
Description check ✅ Passed The description accurately explains the pagination bug and the SQL composition changes that fix it.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch je/fix-sql-pagination

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/liboxen/src/core/db/data_frames/df_db.rs`:
- Around line 520-528: Update count_sql to normalize the caller’s SQL before
embedding it in the derived-table query: parse exactly one statement and remove
its terminal semicolon/delimiter, then compose the count query and downstream
pagination SQL from the normalized statement. Add coverage for
semicolon-terminated SQL while preserving existing behavior for delimiter-free
input.

In `@crates/oxen-server/src/controllers/workspaces/data_frames.rs`:
- Around line 199-214: In the data-frame handler’s count/query flow, split the
combined tasks::spawn_blocking closure into two operations: one closure that
performs repositories::workspaces::data_frames::count_for_query and one that
performs query. Await and propagate errors from each independently while
preserving the existing count and dataframe results.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fe3d00db-de08-4232-bdc6-b5c1651129e8

📥 Commits

Reviewing files that changed from the base of the PR and between 1afd1d3 and 4726824.

📒 Files selected for processing (5)
  • crates/liboxen/src/core/db/data_frames/df_db.rs
  • crates/liboxen/src/core/v_latest/data_frames.rs
  • crates/liboxen/src/repositories/workspaces/data_frames.rs
  • crates/oxen-py/src/py_workspace_data_frame.rs
  • crates/oxen-server/src/controllers/workspaces/data_frames.rs

Comment thread crates/liboxen/src/core/db/data_frames/df_db.rs
Comment thread crates/oxen-server/src/controllers/workspaces/data_frames.rs Outdated
`prepare_sql` and `count_sql` place a caller's statement somewhere other than the
end of the text: before an appended `ORDER BY` or `LIMIT`, or inside a count's
derived table. A trailing `;` or `--` comment is harmless at the end of a
statement and changes what follows it in the middle — the terminator makes the
composed statement a syntax error, and a comment swallows the bounds so the read
returns the whole frame instead of one page.

`add_special_columns` re-renders through the parser only when it injects
`_oxen_id`, and returns the statement as written otherwise: for a DISTINCT, for a
projection that isn't a subset of the source schema, and for one that already
selects `_oxen_id`, which `SELECT *` does because the column is really there. So
the text reaching composition is often the caller's own. Route both composition
sites through `composable` to render a single parsed statement instead.

Text that doesn't parse as exactly one statement passes through unchanged, for
DuckDB to reject as it did before.
The workspace data frame read moved both DuckDB calls off the request thread in
one closure. docs/async_policy.md puts the granularity at one offload per
operation, and specifically not one bespoke closure per handler: sharing a closure
means neither call can overlap with the other or be converted to an async API on
its own. Give each its own offload.

Both already open their own connection, so nothing was shared but the hop.
@jcelliott
jcelliott marked this pull request as ready for review August 12, 2026 15:04

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/liboxen/src/core/db/data_frames/df_db.rs`:
- Around line 539-543: Update composable to catch Parser::parse_sql errors and
return Ok(sql.to_string()) instead of propagating them; continue serializing
only when exactly one statement parses successfully, while returning the
original SQL for zero or multiple statements.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ad386fcb-c35b-4800-b82f-5a36443bb269

📥 Commits

Reviewing files that changed from the base of the PR and between 4726824 and 3e67d7d.

📒 Files selected for processing (3)
  • crates/liboxen/src/core/db/data_frames/df_db.rs
  • crates/liboxen/src/repositories/workspaces/data_frames.rs
  • crates/oxen-server/src/controllers/workspaces/data_frames.rs

Comment thread crates/liboxen/src/core/db/data_frames/df_db.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
crates/liboxen/src/core/db/data_frames/df_db.rs (1)

531-544: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

composable does not implement its documented parse-error fallback.

The doc comment states that text which does not parse as exactly one statement is returned as written, so the database rejects it on its own terms. The implementation propagates Parser::parse_sql errors with ? instead. Only the multi-statement and zero-statement cases fall back to the raw text. Either return Ok(sql.to_string()) on a parse error, or correct the doc to state that unparsable text is rejected by sqlparser. The DIALECT is PostgreSqlDialect, so DuckDB-specific syntax that sqlparser cannot parse now fails with a parser error.

♻️ Option: match the documented behavior
 fn composable(sql: &str) -> Result<String, DataFrameError> {
-    match Parser::parse_sql(&DIALECT, sql)?.as_slice() {
-        [stmt] => Ok(stmt.to_string()),
-        _ => Ok(sql.to_string()),
-    }
+    match Parser::parse_sql(&DIALECT, sql) {
+        Ok(stmts) => match stmts.as_slice() {
+            [stmt] => Ok(stmt.to_string()),
+            _ => Ok(sql.to_string()),
+        },
+        Err(_) => Ok(sql.to_string()),
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/liboxen/src/core/db/data_frames/df_db.rs` around lines 531 - 544,
Update composable to catch Parser::parse_sql errors and return
Ok(sql.to_string()), preserving the documented raw-text fallback for unparsable
input while retaining the existing fallback for zero or multiple statements.
🔇 Additional comments (10)
crates/liboxen/src/core/db/data_frames/df_db.rs (2)

594-607: LGTM!

Also applies to: 616-658


660-675: LGTM!

Also applies to: 677-731

crates/liboxen/src/repositories/workspaces/data_frames.rs (3)

123-138: LGTM!


164-179: 🗄️ Data Integrity & Integration

⚠️ Unverified finding
Sandbox verification was unavailable.

Verify that no caller paginates the result of query a second time.

query now applies opts.page and opts.page_size inside SQL. A caller that also slices or paginates the returned DataFrame with the same opts would apply the page twice and return the wrong rows. The server controller uses from_df_and_opts_unpaginated, so it is safe. Confirm the remaining callers, including export and any client-facing paths, do not re-apply pagination.


2442-2457: LGTM!

Also applies to: 2459-2525, 2527-2578, 2580-2610, 2612-2655, 2657-2683

crates/liboxen/src/core/v_latest/data_frames.rs (1)

165-167: LGTM!

crates/oxen-server/src/controllers/workspaces/data_frames.rs (3)

198-220: LGTM!


1395-1403: 🎯 Functional Correctness

⚠️ Unverified finding
Sandbox verification was unavailable.

Verify that to_http_query_params emits page=0 and page_size=0.

test_get_unindexed_data_frame_paginates sends page_query(0, 10, None) and page_query(1, 0, None) to assert the handler clamps both values to 1. If DFOpts::to_http_query_params skips zero or default values, the request carries no page_size, the handler applies DEFAULT_PAGE_SIZE, and the clamp assertions pass for the wrong reason.


1474-1516: LGTM!

Also applies to: 1526-1620, 1635-1688

crates/oxen-py/src/py_workspace_data_frame.rs (1)

186-192: LGTM!

Also applies to: 236-241

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/oxen-py/src/py_workspace_data_frame.rs`:
- Around line 194-235: Document the pagination constraint in the Python API
surrounding the multi-page query flow: results spanning more than PAGE_SIZE rows
require a caller-supplied ORDER BY for stable pagination, particularly for
DISTINCT, grouped, or derived-table queries. Alternatively, detect the absence
of caller ordering before accumulating multiple pages and return an OxenError
rather than silently returning duplicated or missing rows.

---

Duplicate comments:
In `@crates/liboxen/src/core/db/data_frames/df_db.rs`:
- Around line 531-544: Update composable to catch Parser::parse_sql errors and
return Ok(sql.to_string()), preserving the documented raw-text fallback for
unparsable input while retaining the existing fallback for zero or multiple
statements.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 29e24fbb-aa02-4a09-8e0d-339ac452a579

📥 Commits

Reviewing files that changed from the base of the PR and between 1afd1d3 and 3e67d7d.

📒 Files selected for processing (5)
  • crates/liboxen/src/core/db/data_frames/df_db.rs
  • crates/liboxen/src/core/v_latest/data_frames.rs
  • crates/liboxen/src/repositories/workspaces/data_frames.rs
  • crates/oxen-py/src/py_workspace_data_frame.rs
  • crates/oxen-server/src/controllers/workspaces/data_frames.rs

Comment thread crates/oxen-py/src/py_workspace_data_frame.rs Outdated
The unindexed branch derives a slice from the requested page, and `slice_indices`
reads those bounds back as i64. A page_size only usize can hold overflows that
parse, and the parse panics rather than erroring, so a request naming a page wider
than i64 takes the server down to a 500 instead of reading a page.

Narrow page_size to what the bounds can carry when deriving the slice. The widest
page a request can name then reads as the whole frame, which is what a client
asking for one oversized page means by it.

`slice_indices` still panics on bounds it cannot parse, reachable through `slice`
directly; that expect is worth removing on its own terms.
`sql_query` returns every row a query selects, and read the paginated endpoint a
page at a time to collect them. Pages of one query are not pages of one result: the
read orders a query by `_oxen_row_id` only where that column resolves against it,
so a query that groups or dedupes and carries no `ORDER BY` of its own need not
return rows in the same order twice. Stitching its pages together repeats some rows
and drops others, and answers the query with neither an error nor its result.

Ask for the whole result as a single page instead, which is what this returns
either way — the Python `query(sql=...)` ignores a page number, and
`get_embeddings` reads every row a query matches.
@gschoeni
gschoeni self-requested a review August 12, 2026 21:23
@jcelliott
jcelliott merged commit f88290a into main Aug 13, 2026
9 checks passed
@jcelliott
jcelliott deleted the je/fix-sql-pagination branch August 13, 2026 00:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants